May 26, 2026
10 min read

A percentage of your customers want to use
PayPal, so why are you leaving money on the table using just one payment
provider?
It is not uncommon for developers to use several payment providers over the years. These range from the “big players” such as PayPal and Stripe, to lesser known platforms such as LemonSqueezy, Polar and Paddle. Every platform has its pros and cons, so what if we could decouple from one single payment provider and easily integrate with multiple payment platforms which would bring developers and customers more advantages?
Well we can. Developers no longer need to restrict themselves to one payment platform which they select at the outset and have to remain with it forever. Instead developers can design their checkout so they can use more than one provider behind a clean abstraction! In this blog post I will go through the reasons why, benefits and challenges of using a multi-payment provider in your app, such as PayPal and Stripe.
Most users will be thinking "Why should I have two payment providers?". There are multiple reasons:
You may be convinced by the benefits mentioned above. However a deciding point will be how much extra work integrating two payment providers represents, and whether there are significant complications when trying to do so. Here are some challenges you could face:
These challenges to handle multiple payment providers might sound more complicated than they are. Fundamental Design Patterns that have been used in software engineering for decades can help solve these challenges to keep our project's code clean and flexible.
What are Design Patterns? These are common solutions to frequent occurring problems in software engineering. They resemble pre-made blueprints that you can customize to solve a recurring design problem in your code. Design Patterns are not concrete implementations of code (like a library), but a general concept for solving a particular problem.
Design patterns usually fall into 3 categories: creational, structural, behavioral.
Even when the project will handle multiple payment providers, start building with one payment provider, but architect for two or more from day one. This is where Design Patterns really help, allowing us to create reusable abstractions. For the multiple payment providers there are two areas that will need decoupling, Checkout and Webhook. This is where the Adapter Design Pattern would solve these challenges.
The Adapter Pattern is a Structural Design Pattern that has a very descriptive name. Think of it like a travel adapter. You don't buy a new phone or hair dryer every time you travel to another country that has different wall plugs - you use a travel adapter. The same occurs in code. We have incompatible contracts in code that need to collaborate together in our project, in this specific case the Stripe and PayPal SDKs expose different APIs. The Adapter Design Pattern creates a layer that lets the Checkout or Webhook communicate via a single interface. This allows each payment provider its own specific adapter to handle the custom translation, whilst still adhering to the agreed interface contract.
Let’s take a look at the Adapter Design Pattern with a diagram and then actual code. In this diagram I will use the travel adapter situation that highlights the Adapter Pattern clearly.
The 2 pin plug does not fit in the 3 pin socket. By using an adapter, this will work as the adapter will accept the 2 pin plug and itself has 3 pins to fit into the 3 pin wall socket.
The code blocks below use Typescript classes, but can be applied to any language and even functional code. In this example you will see how we can normalize different payment provider SDKs behind one contract, so these can work together.
The following interfaces show the core idea allowing the checkout service to stay independent of each SDK's details.
ChargeRequest: describes the normalized payment data CheckoutService needs to request a charge.
interface ChargeRequest {
amountCents: number;
currency: Currency;
orderId: string;
}
ChargeResult: represents the provider agnostic result returned after attempting a payment.
interface ChargeResult {
provider: "paypal" | "stripe";
transactionId: string;
approved: boolean;
}
PaymentProvider: defines the shared contract every payment provider Adapter must implement and adhere to.
interface PaymentProvider {
charge(request: ChargeRequest): Promise<ChargeResult>;
refund(transactionId: string, amountCents?: number): Promise<void>;
}
Each payment provider's SDK gets an adapter that converts internal requests into provider native calls.
PayPalAdapter: adapts PayPal's SDK API into the same common PaymentProvider contract.
class PayPalAdapter implements PaymentProvider {
constructor(private readonly paypal: PayPalSdk) {}
async charge(request: ChargeRequest): Promise<ChargeResult> {
const capture = await this.paypal.captureOrder({ /* ... */ });
return {
provider: "paypal",
transactionId: capture.id,
approved: capture.status === "COMPLETED",
};
}
async refund(transactionId: string, amountCents?: number): Promise<void> {
await this.paypal.refundCapture( /* ... */ );
}
}
StripeAdapter: adapts Stripe's SDK API into the same common PaymentProvider contract.
class StripeAdapter implements PaymentProvider {
constructor(private readonly stripe: StripeSdk) {}
async charge(request: ChargeRequest): Promise<ChargeResult> {
const intent = await this.stripe.createPaymentIntent({ /* ... */ });
return {
provider: "stripe",
transactionId: intent.id,
approved: intent.status === "succeeded",
};
}
async refund(transactionId: string, amountCents?: number): Promise<void> {
await this.stripe.createRefund({ /* ... */ });
}
}
CheckoutService: orchestrates checkout flow while remaining independent of any specific provider SDK because the application logic only depends on the shared contract.
class CheckoutService {
constructor(private readonly provider: PaymentProvider) {}
async processOrder(orderId: string, amountCents: number): Promise
const result = await this.provider.charge({
orderId,
amountCents,
currency: "USD",
});
throw new Error(`Payment declined by ${result.provider}`);
}
return result.transactionId;
}
}
Example usage where providers can be swapped without changing the code in the CheckoutService.
const paypalCheckout = new CheckoutService(new PayPalAdapter(new PayPalSdk()));
const stripeCheckout = new CheckoutService(new StripeAdapter(new StripeSdk()));
const paypalTx = await paypalCheckout.processOrder("ORDER-1001", 2599);
const stripeTx = await stripeCheckout.processOrder("ORDER-1002", 2599);
This is the architectural win: checkout code does not care whether it is PayPal or Stripe under the hood.
More payment providers can easily be added by implementing the PaymentProvider interface, which will enforce the rules of our original shared contract so that no matter the new SDK to our project it will work in the same way.
class PaddleAdapter implements PaymentProvider {
constructor(private readonly paddle: PaddleSdk) {}
async charge(request: ChargeRequest): Promise<ChargeResult> {
// ... return {
provider: "paddle",
transactionId: order.id,
approved: order.status === "succeeded",
};
}
async refund(transactionId: string, amountCents?: number): Promise<void> {
// ...
}
}
A dual payment provider strategy is not about adding complexity for its own sake. It is about helping to protect conversion, improve resilience, and give customers payment options they trust.
The Adapter Pattern gives you a clean way to do this: one stable contract for your app and provider-specific encapsulated translation. Start simple with one provider adapter, add the second, and evolve routing based on real production data.
If checkout is a growth surface in your product, this architecture turns payments from a fragile dependency into a competitive advantage.